Unix网络编程-第5章 TCP客户/服务器程序示例

5.1 概述

image-20200212163819379

5.6 正常启动

使用netstat -a检查服务器套接字的状态

在目前众多较新的 Linux 发行版中,已经移除了 net-tools 套件,ifconfig、route、netstat、arp 等一系列工具均无法使用。

新的工具ss 代替了 netstat。

显示系统内的TCP连接,命令:ss -at

netstat用**“*”**号来表示一个为0的IP地址(INADDR_ANY,通配地址)或为0的端口号。

子进程的PPID是父进程的PID,当进程的STAT是“S”表明进程在为等待某些资源而睡眠,进程处于睡眠状态时WCHAN列指出相应的条件。

  • Linux进程阻塞于accept或connect时,输出wait_for_connect
  • 进程阻塞于套接字输入或输出时,输出tcp_data_wait
  • 在进程阻塞于终端I/O时,输出read_chan

5.7 正常终止

正常终止客户和服务器的步骤:

  1. 客户程序输入EOF,fgets返回一个空指针,str_cli函数返回
  2. str_cli函数返回到客户的main函数,main通过调用exit终止
  3. 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字有内核关闭,这导致客户TCP发送一个FIN给服务器,服务器TCP响应ACK,这是TCP连接终止的前半部分。此时服务器套接字处于CLOSE_WAIT状态,客户套接字处于FIN_WAIT_2状态
  4. 当服务器接收FIN时,服务器子进程阻塞于readline调用,readline返回0,这导致str_echo函数返回服务器子进程的main函数
  5. 服务器子进程通过调用exit来终止
  6. 服务器子进程所有描述符随之关闭,子进程关闭已连接套接字会引发TCP连接终止的最后两个分节:一个服务器到客户的FIN和一个客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态
  7. 进程终止处理的另一个部分内容是:在服务器子程序终止时,给父进程发送一个SIGCHLD信号。

5.8 POSIX信号处理

信号就是告知某个进程发生了某个事件的通知,有时也称软件中断

信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻。

信号可以:

  • 由一个进程发送给另一个进程(或自身)
  • 由内核发给某个进程

每个信号都有一个与之关联的处置(disposition),也称为行为(action),我们通常通过调用sigaction函数来设定一个信号的处置,并有三种选择:

  1. 提供一个函数,只要有特定信号发生就被调用,这样的函数称为信号处理函数(signal handler),这种行为称为**捕获(catching)**信号。有两个信号不能被捕获:SIGKILL 和 SIGSTOP。信号处理函数由信号值这个单一的整数参数来调用,没有返回值。

    void handler(int signo);
    
  2. 把某个信号的处置设置为SIG_IGN来忽略它,SIGKILL 和 SIGSTOP不能被忽略。

  3. 把某个信号的处置设定为SIG_DEF来启用它的默认处置。默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image,也称内存映像)。个别信号的默认处置是忽略,例如SIGCHLD 和 SIGURG。

signal函数

建立信号处置的POSIX方法就是调用sigaction函数,但是有点复杂,因为该函数的参数之一是我们必须分配并填写的结构。简答些的方法是调用signal函数,其第一个参数是信号名,第二个参数或为指向函数的指针,或为常值SIG_IGN或SIG_DFL。

signal函数是早于POSIX出现的历史悠久的函数,不同的实现提供不同的信号语义以达成后向兼容。

POSIX则明确规定了调用sigaction时的信号语义。我们定义了自己的signal函数,它只是调用POSIX的sigaction函数,这就以所期望的POSIX语义提供一个简答的接口。

image-20200212205315625

image-20200212210809188

POSIX信号语义

  • 一旦安装了信号处理函数,便一直安装着(早期系统执行一次便拆除)
  • 在一个信号处理函数运行期间,正被递交的信号是阻塞的,而且sa_mask信号集中指定的任何额外信号也被阻塞
  • 如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次,也就是说Unix信号默认是不排队的
  • 利用sigprocmask函数选择性地阻塞或解阻塞一组信号是可能的。可以做到在临界区代码执行期间,防止捕获某些信号,以此保护这段代码

5.9 处理SIGCHLD信号

设置僵死(zombie)状态的目的是维护子进程的信息(子进程的进程ID、终止状态以及资源利用信息(CPU时间、内存使用量等等)),以便父进程在以后某个时候获取。

如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将重置为1(init进程)。继承这些子进程的init进程将清理它们。

处理僵死进程

僵死进程占用内核中的空间,可能导致耗尽进程资源。

无论何时我们fork子进程都得wait它们,防止它们变成僵死进程,为此我们建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait。

Signal(SIGCHLD, sig_chld);

void sig_child(int signo){
    pid_t    pid;
    int      stat;
    
    pid = wait(&stat);
    //在信号处理函数中调用诸如printf这样的标准I/O函数是不合适的
    printf("child %d terminated\n", pid);
    return;
}

处理被中断的系统调用

慢系统调用适用于那些可能永远阻塞的系统调用,永远阻塞的系统调用是指调用有可能永远无法返回,多数网络支持函数都属于这一类。

适用于慢系统调用的基本规则:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误(被中断的系统调用)。有些内核自动重启某些被中断的系统调用。

由于“可能”、“有些”以及对POSIX的SA_RESTART标志的支持是可选的,我们必须考虑可移植性问题。为了便于移植,我们编写捕获信号的程序时(多数并发服务器捕获SIGCHLD),我们必须对慢系统调用返回EINTR有所准备。

for( ; ; ){
    client = sizeof(cliaddr);
    if((connfd = accept(listenfd, (SA *) &cliaddr, &clinet)) < 0 ){
        if(errno == EINTR)
            continue;
        else
            err_sys("accept error");
    }
}

我们可以编写函数,自己重启被中断的系统调用,如accept、read、write、select和open之类的函数,

但是connect函数我们不能重启:如果该函数返回EINTR,我们就不能再次调用它,否则将立即返回一个错误,当connect被一个捕获的信号中断而且不自动重启时,我们必须调用select来等待连接完成。

5.10 wait和waitpid

#include <sys/wait.h>

//成功返回进程ID,出错返回0或-1
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函数wait和waitpid均返回两个值

  1. 已终止子进程的进程ID号
  2. 通过statloc指针返回子进程终止状态(一个整数)

调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止为止。

waitpid函数就等待哪个进程终止以及是否阻塞给了我们更多的控制:

  1. pid参数允许我们指定想等待的进程ID,值-1表示等待第一个终止的子进程
  2. options参数允许我们指定附加选项,常用选项是WNOHANG,告知内核在没有已终止子进程时不要阻塞。

函数wait和waitpid的区别

若有多个子进程同时终止,则同一时刻有5个SIGCHLD信号递交给父进程,因为Unix信号一般是不排序的,导致调用wait的信号处理函数只执行了一次。而使用waitpid函数,则可以在一个循环内调用(无法防止wait在正在运行的子进程尚有未终止时阻塞,不能在循环内调用wait),以获取所有已终止子进程的状态,这时必须指定WNOHANG选项,告知waitpid在尚有未终止的子进程在运行时不阻塞。

本节的目的是示范我们在网络编程时可能会遇到的三种情况:

  1. 当fork子进程时,必须捕获SIGCHLD信号
  2. 当捕获信号时,必须处理被中断的系统调用
  3. SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程
//处理accept返回EINIT的TCP服务器程序最终(正确)版
#include	"unp.h"

int
main(int argc, char **argv)
{
	int					listenfd, connfd;
	pid_t				childpid;
	socklen_t			clilen;
	struct sockaddr_in	cliaddr, servaddr;
	void				sig_chld(int);

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

	Listen(listenfd, LISTENQ);

	Signal(SIGCHLD, sig_chld);	/* must call waitpid() */

	for ( ; ; ) {
		clilen = sizeof(cliaddr);
		if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
			if (errno == EINTR)
				continue;		/* back to for() */
			else
				err_sys("accept error");
		}

		if ( (childpid = Fork()) == 0) {	/* child process */
			Close(listenfd);	/* close listening socket */
			str_echo(connfd);	/* process the request */
			exit(0);
		}
		Close(connfd);			/* parent closes connected socket */
	}
}

5.11 accept返回前连接中止

三次握手完成建立连接后,客户TCP却发送RST,服务器端该连接已由TCP排队,在等待accept时RST到达,稍后服务器进程调用accept。

image-20200213115625378

处理连接中止依赖于不同的实现,大多数返回一个错误给服务器,作为accept的返回结果,POSIX指出返回的errno值必须是ECONNABORTED(“software caused connection abort”,软件引起的连接中止)。遇到此错误,服务器可以选择忽略它,再次调用accept函数。

5.12 服务器进程终止

启动客户/服务器,然后杀死服务器子进程,模拟服务器进程崩溃时的情况:

  1. 于同一台主机启动客户和服务器,验证一切正常

  2. 找到服务器子进程ID,kill掉它。进程终止的处理工作会关闭子进程中所有打开着的描述符,导致向客户发送一个FIN,而客户响应一个ACK(TCP连接终止工作的前半部分)

  3. SIGHELD信号发送给服务器父进程,并得到正确处理

  4. 客户接受到FIN并响应ACK的时候,正阻塞在fgets调用上,等待终端接收一个文本

  5. 当客户在终端上输入文本时,将调用writen,客户TCP将把数据发送给服务器,当服务器接收到数据后,由于先前打开的描述符已经终止,于是响应一个RST

    客户TCP接收到FIN只是表示服务器进程已关闭了连接的服务器端,从而(服务器)不再往其发送任何数据。FIN的接收并没有告知客户TCP服务器进程已经终止。
    
  6. 而客户进程看不到这个RST,因为调用writen后立即调用readline,并由于步骤2接收的FIN,readline立即返回0(EOF)。客户此时未预期收到EOF,于是返回出错信息“server terminated prematurely”(服务器过早终止)退出

  7. 客户终止,所有打开着的描述符都被关闭

5.13 SIGPIPE信号

当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。

无论进程是捕获了该信号并从其信号处理函数返回,还是简答地忽略该信号,写操作都将返回EPIPE错误

如果不理会readline函数返回的错误,反而写更多的数据到服务器上,客户在读回任何数据之前执行两次对服务器的写操作,第一次写操作引发RST,第二次写操作引发SIGPIPE信号。

写一个已接受了FIN的套接字不成问题,但是写一个接收了RST的套接字则是一个错误。

处理SIGPIPE的建议方法取决于它发生时应用进程想做什么,如果没有特殊的事情要做,那么将信号处理方法直接设置为SIG_IGN,并假设后续的输出操作将捕捉EPIPE错误并终止。

如果使用多个套接字,该信号的提交无法告诉我们是哪个套接字出的错,如果确实需要知道是哪个write出了错,那么要么不理会该信号,要么从信号处理函数返回后再处理来自write的PIPE。

5.14 服务器主机崩溃

  1. 当服务器主机崩溃时,已有的网络连接上不发出任何东西
  2. 当客户键入文本,由writen写入内核,再由客户TCP作为一个数据分节送出,阻塞于readline,等待回射应答
  3. 客户TCP将持续重传数据分节,试图从服务器上接收一个ACK。若服务器已崩溃,从而对客户的数据分节根本没有响应,那么返回的错误是ETIMEOUT,然而如果某个中间的路由器判定服务器已不可到达,从而响应一个“destination unreachable”(目的地不可到达)ICMP信息,那么返回的错误是EHOSTUNREACH或ENETHNREACH

通过设置超时可以及时检测出不可到达的情况。如果不主动向它发送数据也想检测出服务器主机的崩溃,那么就需要SO_KEEPALIVE套接字选项。

5.15 服务器主机崩溃后重启

  1. 当服务器主机崩溃重启后,它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于收到的来自客户的数据分节响应以一个RST。
  2. 客户TCP收到RST时,客户正阻塞于readline调用,导致该调用返回ECONNRESET错误。

5.16 服务器主机关机

Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不可被捕获)。这么做留个所有运行的进程一小段时间来清除和终止。

  • 如果我们忽略SIGTERM信号,我们的服务器将由SIGKILL信号终止
  • 如果我们不捕获也不忽略SIGTERM信号,那么起作用的是SIGTERM的默认处置(终止进程),那么服务器将被SIGTERM信号终止,SIGKILL信号不可能再发送给服务器

5.17 TCP程序例子小结

从客户角度总结TCP客户/服务器

image-20200218171852426

从服务器角度总结TCP客户/服务器

image-20200218172026706

5.18 数据格式

传递二进制数据可能存在的问题:

  1. 不同的实现以不同的格式存储二进制数,大端字节序和小端字节序
  2. 不同的实现在存储相同的C数据类型上可能存在差异,32位系统使用32位表示long,64位系统使用64位
  3. 不同的实现给结构打包的方式存在差异,取决于各种数据类型所使用的位数以及机器的对齐限制

处理数据格式问题常用方法:

  1. 把所有的数值数据作为文本串来传递(客户和服务器主机具有相同的字符集)
  2. 显示定义所支持数据类型的二进制格式(数位、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据,远程过程调用(RPC)软件包通常使用这种技术